上週是震驚期:DI 沒了、生命週期沒了、雙向綁定沒了,我一路在找對應方法,然後一路就是沒有。
這週換了個態度。找不到對應方法,那我就自己試著模擬看看。
RxJS 是我在 Angular 裡最倚賴的東西。switchMap、debounceTime、combineLatest——三年來所有非同步的問題,我都是用這套詞彙在思考的。我不相信換個框架,這套思考方式就沒用了。
所以這週的計畫是:能用 Angular 的方式解決的,就用 Angular 的方式先解決。
結果也是冒出一堆疑問了。
檔案頁要先載入清單。我照 Angular 的習慣,先把資料來源「準備好」:
檔案頁有一個「匯出」按鈕。我照 Angular 的習慣,先把資料來源準備好,等使用者按下去再用:
export function FileList() {
const [keyword, setKeyword] = useState('');
const filesPromise = api.getFiles(); // 先準備著,匯出時才用
async function handleExport() {
const files = await filesPromise;
exportCsv(files);
}
return (
<>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<button onClick={handleExport}>匯出</button>
</>
);
}
打開 Network 面板:一進頁面,/api/files 就出去了。在搜尋框打一個字,又一次。
我根本還沒按匯出。
在 Angular,這樣寫是不會發請求的。
在 Angular,HttpClient 回傳的是 Observable:
const files$ = this.http.get<File[]>('/api/files');
// 到這裡為止,什麼都沒發生
這行只是描述了一個請求。它不會打 API、不會等回應、不會做任何事。要等到有人訂閱,管線建立了要有人來打開才行:
files$.subscribe(files => this.files = files); // 現在才真的發出去
或者交給模板:
@for (file of files$ | async; track file.id) { ... }
這個特性叫 lazy(惰性):Observable 是一份藍圖,不是一個正在進行的動作。你可以把它傳來傳去、組合、轉換,只要沒人訂閱,就一個請求都不會出去。
所以最常見的新手 bug 是忘記 subscribe,結果請求根本沒發。
JavaScript 的 Promise 剛好相反。
const filesPromise = fetch('/api/files');
// 到這裡,請求已經出去了
Promise 是 eager的。建立的那一刻,裡面的工作就開始跑;.then() 只是讓你在它完成時拿到結果,跟「要不要執行」無關。
async 函式也一樣:
async function getFiles() {
return (await fetch('/api/files')).json();
}
getFiles(); // 呼叫的瞬間就發出去了
回到我的 bug。api.getFiles() 寫在元件函式本體裡,而 Day 5 說過,元件函式每次 render 都會整個重跑一次。
所以每一次 render,那行都會被執行,都會建立一個新的 Promise,都會發出一個新的請求。useEffect 只跑一次沒錯,但它等的那個 Promise 早就不是唯一一個了——其他幾個請求發出去,結果沒人接。
在 Angular,一個沒被訂閱的 Observable 不會造成任何後果。在 React,一個不小心寫在 render 裡的 Promise,每次 render 都在花你的 API 額度。
修法很直接,把它搬進 effect:
useEffect(() => {
api.getFiles().then(setFiles);
}, []);
在 React 裡,「建立 Promise」本身就是副作用,它該待在副作用該待的地方。
lazy 跟 eager 只是第一個。把兩邊攤開來看:
| Observable | Promise | |
|---|---|---|
| 什麼時候開始 | 被訂閱時(lazy) | 建立時(eager) |
| 能給幾個值 | 零到無限多個 | 恰好一個 |
| 能不能取消 | unsubscribe() |
不能 |
| 怎麼組合 | 上百個運算子 | .then() 加上 Promise.all 那幾個 |
不過要誠實說一句:第二列在 HTTP 請求上其實沒差。 Angular 的 http.get() 也只會發出一個值然後結束,跟 Promise 一樣。多值的威力在事件、WebSocket、計時器這類東西上才看得出來。
真正讓我這週一直撞牆的,是另外三列:
不能取消。 Promise 一旦出發就回不來。使用者切了頁、打了新的關鍵字,舊的請求還是會跑完、還是會 resolve,你只能在它回來時選擇忽略。瀏覽器提供了 AbortController 可以中止 fetch,但那是 fetch 的能力,不是 Promise 的——這個明天會專門講。
沒有運算子。 debounceTime、distinctUntilChanged、retry、switchMap——RxJS 裡一行就解決的事,在 Promise 的世界全部要自己寫。
不是一條管線。 這個最根本,下一節說。
RxJS 不是 Angular 的專屬品,它是一個獨立的套件。React 專案一樣能 npm install rxjs。
所以我寫了第一個 hook:
function useObservable<T>(source$: Observable<T>, initial: T) {
const [value, setValue] = useState(initial);
useEffect(() => {
const sub = source$.subscribe(setValue);
return () => sub.unsubscribe();
}, [source$]);
return value;
}
基本上就是自己刻了一個 | async。能動,而且當下覺得很有成就感。
然後問題一個一個浮上來。
source$ 每次 render 都是新的。 在元件裡寫 const files$ = api.getFiles$(),Day 4 的問題原封不動重演——依賴陣列看到新參考,每次都重新訂閱。要嘛 useMemo 包起來,要嘛搬到元件外面。每一個 Observable 都得這樣處理。
StrictMode 下會訂閱兩次。 開發模式故意跑兩次 effect,cold Observable 就真的發兩次請求。清除函式寫對了不會壞,但 Network 面板會一直讓你懷疑自己。
整個生態系都在講 Promise。 React Router 的 loader 要的是 Promise。資料請求套件要的是 Promise。表單套件的非同步驗證要的是 Promise。我在一個 Promise 的世界裡,硬是維護一條 Observable 的平行線,每到邊界就要轉換一次。
到這裡我意識到,問題不只是 API 不相容。
在 Angular,我把資料想成一條會流動的管線:
this.files$ = this.keyword$.pipe(
debounceTime(300),
switchMap(k => this.api.search(k)),
);
keyword$ 是「關鍵字隨時間變化的序列」,files$ 是「搜尋結果隨時間變化的序列」。整段程式碼描述的是時間軸上的關係:每當上游有新值,下游就跟著變。
React 不是這樣思考的。
在 React,keyword 就是一個值——此時此刻的值。沒有序列,沒有管線,只有一個字串。
const [keyword, setKeyword] = useState('');
那「隨時間變化」這件事去哪了?答案是 re-render。
每次 keyword 變了,元件函式整個重跑一次,拿到新的值,算出新的畫面。時間不是被描述在資料流裡,而是被切成一格一格的 render——每一次 render 都是一張快照,快照裡的每個值都是靜止的。
所以 Observable 在 React 裡總覺得格格不入。它想描述的「隨時間變化」,React 已經用自己的方式處理掉了:你不需要一條管線把值送到畫面,因為元件每次都會重新拿一次最新的值。
Observable 需要時間軸,React 只給你快照。兩種世界觀在搶同一件事的主導權。
這不代表 RxJS 在 React 裡沒用。WebSocket、複雜的事件組合、多個來源的合併——這些本質上就是串流的東西,Observable 依然是最好的描述方式(Week 4 會遇到)。但拿它來處理「一個請求、拿一個結果」這種日常,就像用一整套水管系統去裝一杯水。
Observable 跟 Promise 看起來都是「非同步的值」,但它們回答的是不同的問題。
Observable 是一份藍圖:lazy、可以取消、可以有很多個值、有一整套運算子,描述的是一條隨時間流動的管線。
Promise 是一張收據:eager、不能取消、只有一個值,描述的是一件已經開始、未來會有結果的事。
Angular 選了前者,所以整個框架都建立在「資料是串流」的假設上。React 選了後者,因為它根本不需要串流——時間被 re-render 切成快照,每張快照裡只需要當下的值。
那個一直發出去的請求,就是兩種假設撞在一起的地方:我以為我在畫藍圖,其實每一次 render 都在簽一張新收據。
RxJS 我先收起來了,但它教我的那些問題不會消失——使用者打字太快怎麼辦、舊的請求比新的晚回來怎麼辦、兩個請求要一起等怎麼辦。
明天討論:switchMap 不見了,我要自己處理競態。